@threadlabs/looma 0.28.20 → 0.28.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1732,144 +1732,11 @@ var TaskList = Node$1.create({
1732
1732
  }
1733
1733
  });
1734
1734
  //#endregion
1735
- //#region ../../node_modules/.pnpm/@tiptap+extension-task-item@2.27.2_@tiptap+core@2.27.2_@tiptap+pm@2.27.2__@tiptap+pm@2.27.2/node_modules/@tiptap/extension-task-item/dist/index.js
1736
- /**
1737
- * Matches a task item to a - [ ] on input.
1738
- */
1739
- var inputRegex$4 = /^\s*(\[([( |x])?\])\s$/;
1740
- /**
1741
- * This extension allows you to create task items.
1742
- * @see https://www.tiptap.dev/api/nodes/task-item
1743
- */
1744
- var TaskItem = Node$1.create({
1745
- name: "taskItem",
1746
- addOptions() {
1747
- return {
1748
- nested: false,
1749
- HTMLAttributes: {},
1750
- taskListTypeName: "taskList",
1751
- a11y: void 0
1752
- };
1753
- },
1754
- content() {
1755
- return this.options.nested ? "paragraph block*" : "paragraph+";
1756
- },
1757
- defining: true,
1758
- addAttributes() {
1759
- return { checked: {
1760
- default: false,
1761
- keepOnSplit: false,
1762
- parseHTML: (element) => {
1763
- const dataChecked = element.getAttribute("data-checked");
1764
- return dataChecked === "" || dataChecked === "true";
1765
- },
1766
- renderHTML: (attributes) => ({ "data-checked": attributes.checked })
1767
- } };
1768
- },
1769
- parseHTML() {
1770
- return [{
1771
- tag: `li[data-type="${this.name}"]`,
1772
- priority: 51
1773
- }];
1774
- },
1775
- renderHTML({ node, HTMLAttributes }) {
1776
- return [
1777
- "li",
1778
- mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { "data-type": this.name }),
1779
- [
1780
- "label",
1781
- ["input", {
1782
- type: "checkbox",
1783
- checked: node.attrs.checked ? "checked" : null
1784
- }],
1785
- ["span"]
1786
- ],
1787
- ["div", 0]
1788
- ];
1789
- },
1790
- addKeyboardShortcuts() {
1791
- const shortcuts = {
1792
- Enter: () => this.editor.commands.splitListItem(this.name),
1793
- "Shift-Tab": () => this.editor.commands.liftListItem(this.name)
1794
- };
1795
- if (!this.options.nested) return shortcuts;
1796
- return {
1797
- ...shortcuts,
1798
- Tab: () => this.editor.commands.sinkListItem(this.name)
1799
- };
1800
- },
1801
- addNodeView() {
1802
- return ({ node, HTMLAttributes, getPos, editor }) => {
1803
- const listItem = document.createElement("li");
1804
- const checkboxWrapper = document.createElement("label");
1805
- const checkboxStyler = document.createElement("span");
1806
- const checkbox = document.createElement("input");
1807
- const content = document.createElement("div");
1808
- const updateA11Y = () => {
1809
- var _a, _b;
1810
- checkbox.ariaLabel = ((_b = (_a = this.options.a11y) === null || _a === void 0 ? void 0 : _a.checkboxLabel) === null || _b === void 0 ? void 0 : _b.call(_a, node, checkbox.checked)) || `Task item checkbox for ${node.textContent || "empty task item"}`;
1811
- };
1812
- updateA11Y();
1813
- checkboxWrapper.contentEditable = "false";
1814
- checkbox.type = "checkbox";
1815
- checkbox.addEventListener("mousedown", (event) => event.preventDefault());
1816
- checkbox.addEventListener("change", (event) => {
1817
- if (!editor.isEditable && !this.options.onReadOnlyChecked) {
1818
- checkbox.checked = !checkbox.checked;
1819
- return;
1820
- }
1821
- const { checked } = event.target;
1822
- if (editor.isEditable && typeof getPos === "function") editor.chain().focus(void 0, { scrollIntoView: false }).command(({ tr }) => {
1823
- const position = getPos();
1824
- if (typeof position !== "number") return false;
1825
- const currentNode = tr.doc.nodeAt(position);
1826
- tr.setNodeMarkup(position, void 0, {
1827
- ...currentNode === null || currentNode === void 0 ? void 0 : currentNode.attrs,
1828
- checked
1829
- });
1830
- return true;
1831
- }).run();
1832
- if (!editor.isEditable && this.options.onReadOnlyChecked) {
1833
- if (!this.options.onReadOnlyChecked(node, checked)) checkbox.checked = !checkbox.checked;
1834
- }
1835
- });
1836
- Object.entries(this.options.HTMLAttributes).forEach(([key, value]) => {
1837
- listItem.setAttribute(key, value);
1838
- });
1839
- listItem.dataset.checked = node.attrs.checked;
1840
- checkbox.checked = node.attrs.checked;
1841
- checkboxWrapper.append(checkbox, checkboxStyler);
1842
- listItem.append(checkboxWrapper, content);
1843
- Object.entries(HTMLAttributes).forEach(([key, value]) => {
1844
- listItem.setAttribute(key, value);
1845
- });
1846
- return {
1847
- dom: listItem,
1848
- contentDOM: content,
1849
- update: (updatedNode) => {
1850
- if (updatedNode.type !== this.type) return false;
1851
- listItem.dataset.checked = updatedNode.attrs.checked;
1852
- checkbox.checked = updatedNode.attrs.checked;
1853
- updateA11Y();
1854
- return true;
1855
- }
1856
- };
1857
- };
1858
- },
1859
- addInputRules() {
1860
- return [wrappingInputRule({
1861
- find: inputRegex$4,
1862
- type: this.type,
1863
- getAttributes: (match) => ({ checked: match[match.length - 1] === "x" })
1864
- })];
1865
- }
1866
- });
1867
- //#endregion
1868
1735
  //#region ../../node_modules/.pnpm/@tiptap+extension-blockquote@2.27.2_@tiptap+core@2.27.2_@tiptap+pm@2.27.2_/node_modules/@tiptap/extension-blockquote/dist/index.js
1869
1736
  /**
1870
1737
  * Matches a blockquote to a `>` as input.
1871
1738
  */
1872
- var inputRegex$3 = /^\s*>\s$/;
1739
+ var inputRegex$4 = /^\s*>\s$/;
1873
1740
  /**
1874
1741
  * This extension allows you to create blockquotes.
1875
1742
  * @see https://tiptap.dev/api/nodes/blockquote
@@ -1910,7 +1777,7 @@ var Blockquote = Node$1.create({
1910
1777
  },
1911
1778
  addInputRules() {
1912
1779
  return [wrappingInputRule({
1913
- find: inputRegex$3,
1780
+ find: inputRegex$4,
1914
1781
  type: this.type
1915
1782
  })];
1916
1783
  }
@@ -3861,7 +3728,7 @@ var Link = Mark.create({
3861
3728
  /**
3862
3729
  * Matches an image to a ![image](src "title") on input.
3863
3730
  */
3864
- var inputRegex$2 = /(?:^|\s)(!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\))$/;
3731
+ var inputRegex$3 = /(?:^|\s)(!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\))$/;
3865
3732
  /**
3866
3733
  * This extension allows you to insert images.
3867
3734
  * @see https://www.tiptap.dev/api/nodes/image
@@ -3905,7 +3772,7 @@ var Image = Node$1.create({
3905
3772
  },
3906
3773
  addInputRules() {
3907
3774
  return [nodeInputRule({
3908
- find: inputRegex$2,
3775
+ find: inputRegex$3,
3909
3776
  type: this.type,
3910
3777
  getAttributes: (match) => {
3911
3778
  const [, , alt, src, title] = match;
@@ -3962,7 +3829,7 @@ var LoomaImage = Image.extend({ addAttributes() {
3962
3829
  /**
3963
3830
  * Matches a highlight to a ==highlight== on input.
3964
3831
  */
3965
- var inputRegex$1 = /(?:^|\s)(==(?!\s+==)((?:[^=]+))==(?!\s+==))$/;
3832
+ var inputRegex$2 = /(?:^|\s)(==(?!\s+==)((?:[^=]+))==(?!\s+==))$/;
3966
3833
  /**
3967
3834
  * Matches a highlight to a ==highlight== on paste.
3968
3835
  */
@@ -4021,7 +3888,7 @@ var Highlight = Mark.create({
4021
3888
  },
4022
3889
  addInputRules() {
4023
3890
  return [markInputRule({
4024
- find: inputRegex$1,
3891
+ find: inputRegex$2,
4025
3892
  type: this.type
4026
3893
  })];
4027
3894
  },
@@ -4244,7 +4111,7 @@ var Placeholder = Extension.create({
4244
4111
  * This ensures that any text between backticks is formatted as code,
4245
4112
  * regardless of the surrounding characters (exception being another backtick).
4246
4113
  */
4247
- var inputRegex = /(^|[^`])`([^`]+)`(?!`)/;
4114
+ var inputRegex$1 = /(^|[^`])`([^`]+)`(?!`)/;
4248
4115
  /**
4249
4116
  * Matches inline code while pasting.
4250
4117
  */
@@ -4289,7 +4156,7 @@ var Code = Mark.create({
4289
4156
  },
4290
4157
  addInputRules() {
4291
4158
  return [markInputRule({
4292
- find: inputRegex,
4159
+ find: inputRegex$1,
4293
4160
  type: this.type
4294
4161
  })];
4295
4162
  },
@@ -6431,6 +6298,196 @@ var LoomaBlockSelection = Extension.create({
6431
6298
  }
6432
6299
  });
6433
6300
  //#endregion
6301
+ //#region ../../node_modules/.pnpm/@tiptap+extension-task-item@2.27.2_@tiptap+core@2.27.2_@tiptap+pm@2.27.2__@tiptap+pm@2.27.2/node_modules/@tiptap/extension-task-item/dist/index.js
6302
+ /**
6303
+ * Matches a task item to a - [ ] on input.
6304
+ */
6305
+ var inputRegex = /^\s*(\[([( |x])?\])\s$/;
6306
+ /**
6307
+ * This extension allows you to create task items.
6308
+ * @see https://www.tiptap.dev/api/nodes/task-item
6309
+ */
6310
+ var TaskItem = Node$1.create({
6311
+ name: "taskItem",
6312
+ addOptions() {
6313
+ return {
6314
+ nested: false,
6315
+ HTMLAttributes: {},
6316
+ taskListTypeName: "taskList",
6317
+ a11y: void 0
6318
+ };
6319
+ },
6320
+ content() {
6321
+ return this.options.nested ? "paragraph block*" : "paragraph+";
6322
+ },
6323
+ defining: true,
6324
+ addAttributes() {
6325
+ return { checked: {
6326
+ default: false,
6327
+ keepOnSplit: false,
6328
+ parseHTML: (element) => {
6329
+ const dataChecked = element.getAttribute("data-checked");
6330
+ return dataChecked === "" || dataChecked === "true";
6331
+ },
6332
+ renderHTML: (attributes) => ({ "data-checked": attributes.checked })
6333
+ } };
6334
+ },
6335
+ parseHTML() {
6336
+ return [{
6337
+ tag: `li[data-type="${this.name}"]`,
6338
+ priority: 51
6339
+ }];
6340
+ },
6341
+ renderHTML({ node, HTMLAttributes }) {
6342
+ return [
6343
+ "li",
6344
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { "data-type": this.name }),
6345
+ [
6346
+ "label",
6347
+ ["input", {
6348
+ type: "checkbox",
6349
+ checked: node.attrs.checked ? "checked" : null
6350
+ }],
6351
+ ["span"]
6352
+ ],
6353
+ ["div", 0]
6354
+ ];
6355
+ },
6356
+ addKeyboardShortcuts() {
6357
+ const shortcuts = {
6358
+ Enter: () => this.editor.commands.splitListItem(this.name),
6359
+ "Shift-Tab": () => this.editor.commands.liftListItem(this.name)
6360
+ };
6361
+ if (!this.options.nested) return shortcuts;
6362
+ return {
6363
+ ...shortcuts,
6364
+ Tab: () => this.editor.commands.sinkListItem(this.name)
6365
+ };
6366
+ },
6367
+ addNodeView() {
6368
+ return ({ node, HTMLAttributes, getPos, editor }) => {
6369
+ const listItem = document.createElement("li");
6370
+ const checkboxWrapper = document.createElement("label");
6371
+ const checkboxStyler = document.createElement("span");
6372
+ const checkbox = document.createElement("input");
6373
+ const content = document.createElement("div");
6374
+ const updateA11Y = () => {
6375
+ var _a, _b;
6376
+ checkbox.ariaLabel = ((_b = (_a = this.options.a11y) === null || _a === void 0 ? void 0 : _a.checkboxLabel) === null || _b === void 0 ? void 0 : _b.call(_a, node, checkbox.checked)) || `Task item checkbox for ${node.textContent || "empty task item"}`;
6377
+ };
6378
+ updateA11Y();
6379
+ checkboxWrapper.contentEditable = "false";
6380
+ checkbox.type = "checkbox";
6381
+ checkbox.addEventListener("mousedown", (event) => event.preventDefault());
6382
+ checkbox.addEventListener("change", (event) => {
6383
+ if (!editor.isEditable && !this.options.onReadOnlyChecked) {
6384
+ checkbox.checked = !checkbox.checked;
6385
+ return;
6386
+ }
6387
+ const { checked } = event.target;
6388
+ if (editor.isEditable && typeof getPos === "function") editor.chain().focus(void 0, { scrollIntoView: false }).command(({ tr }) => {
6389
+ const position = getPos();
6390
+ if (typeof position !== "number") return false;
6391
+ const currentNode = tr.doc.nodeAt(position);
6392
+ tr.setNodeMarkup(position, void 0, {
6393
+ ...currentNode === null || currentNode === void 0 ? void 0 : currentNode.attrs,
6394
+ checked
6395
+ });
6396
+ return true;
6397
+ }).run();
6398
+ if (!editor.isEditable && this.options.onReadOnlyChecked) {
6399
+ if (!this.options.onReadOnlyChecked(node, checked)) checkbox.checked = !checkbox.checked;
6400
+ }
6401
+ });
6402
+ Object.entries(this.options.HTMLAttributes).forEach(([key, value]) => {
6403
+ listItem.setAttribute(key, value);
6404
+ });
6405
+ listItem.dataset.checked = node.attrs.checked;
6406
+ checkbox.checked = node.attrs.checked;
6407
+ checkboxWrapper.append(checkbox, checkboxStyler);
6408
+ listItem.append(checkboxWrapper, content);
6409
+ Object.entries(HTMLAttributes).forEach(([key, value]) => {
6410
+ listItem.setAttribute(key, value);
6411
+ });
6412
+ return {
6413
+ dom: listItem,
6414
+ contentDOM: content,
6415
+ update: (updatedNode) => {
6416
+ if (updatedNode.type !== this.type) return false;
6417
+ listItem.dataset.checked = updatedNode.attrs.checked;
6418
+ checkbox.checked = updatedNode.attrs.checked;
6419
+ updateA11Y();
6420
+ return true;
6421
+ }
6422
+ };
6423
+ };
6424
+ },
6425
+ addInputRules() {
6426
+ return [wrappingInputRule({
6427
+ find: inputRegex,
6428
+ type: this.type,
6429
+ getAttributes: (match) => ({ checked: match[match.length - 1] === "x" })
6430
+ })];
6431
+ }
6432
+ });
6433
+ //#endregion
6434
+ //#region src/editor/extensions/task-item.ts
6435
+ var taskSequence = 0;
6436
+ /** A task item's accessible name: its text, or "Task" while it has none. */
6437
+ var taskName = (node) => node.textContent.trim() || "Task";
6438
+ /**
6439
+ * Tiptap's task item with each checkbox named by its item's text, so a screen reader announces
6440
+ * the task rather than an unnamed checkbox. In the editor the checkbox points at the item's own
6441
+ * content with `aria-labelledby`, so the name follows every edit; saved HTML has no element to point
6442
+ * at, so its checkbox carries the text as `aria-label`.
6443
+ */
6444
+ var LoomaTaskItem = TaskItem.extend({
6445
+ renderHTML({ node, HTMLAttributes }) {
6446
+ return [
6447
+ "li",
6448
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { "data-type": this.name }),
6449
+ [
6450
+ "label",
6451
+ ["input", {
6452
+ type: "checkbox",
6453
+ checked: node.attrs.checked ? "checked" : null,
6454
+ "aria-label": taskName(node)
6455
+ }],
6456
+ ["span"]
6457
+ ],
6458
+ ["div", 0]
6459
+ ];
6460
+ },
6461
+ addNodeView() {
6462
+ const parent = this.parent();
6463
+ return (props) => {
6464
+ const view = parent(props);
6465
+ const checkbox = view.dom instanceof Element ? view.dom.querySelector("input[type=\"checkbox\"]") : null;
6466
+ const content = view.contentDOM;
6467
+ if (!checkbox || !(content instanceof HTMLElement)) return view;
6468
+ content.id ||= `looma-task-${++taskSequence}`;
6469
+ const name = (node) => {
6470
+ if (node.textContent.trim()) {
6471
+ checkbox.setAttribute("aria-labelledby", content.id);
6472
+ checkbox.removeAttribute("aria-label");
6473
+ } else {
6474
+ checkbox.removeAttribute("aria-labelledby");
6475
+ checkbox.setAttribute("aria-label", "Task");
6476
+ }
6477
+ };
6478
+ name(props.node);
6479
+ return {
6480
+ ...view,
6481
+ update: (...args) => {
6482
+ const kept = view.update?.(...args) ?? false;
6483
+ if (kept) name(args[0]);
6484
+ return kept;
6485
+ }
6486
+ };
6487
+ };
6488
+ }
6489
+ });
6490
+ //#endregion
6434
6491
  //#region src/editor/extensions/list-behavior.ts
6435
6492
  /**
6436
6493
  * Looma list behavior extension:
@@ -10900,7 +10957,7 @@ function getDefaultEditorExtensions(options = {}) {
10900
10957
  OrderedList,
10901
10958
  ListItem,
10902
10959
  TaskList,
10903
- TaskItem.configure({ nested: false }),
10960
+ LoomaTaskItem.configure({ nested: false }),
10904
10961
  Blockquote,
10905
10962
  calloutNodeView ? LoomaCallout.extend({ addNodeView: () => calloutNodeView }) : LoomaCallout,
10906
10963
  LoomaChip,
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Tiptap's task item with each checkbox named by its item's text, so a screen reader announces
3
+ * the task rather than an unnamed checkbox. In the editor the checkbox points at the item's own
4
+ * content with `aria-labelledby`, so the name follows every edit; saved HTML has no element to point
5
+ * at, so its checkbox carries the text as `aria-label`.
6
+ */
7
+ export declare const LoomaTaskItem: import("@tiptap/core").Node<import("@tiptap/extension-task-item").TaskItemOptions, any>;
@@ -0,0 +1,49 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ import { type LoomaImageDescriptor } from "./image-delivery.js";
3
+ /**
4
+ * Host upload result normalized into the editor's durable image descriptor.
5
+ * `url` becomes the stored `src`; optional dimensions preserve layout before
6
+ * the image loads and `responsive` opts into host-provided rendition policy.
7
+ */
8
+ export interface LoomaImageUploadResult extends Omit<LoomaImageDescriptor, "src"> {
9
+ url: string;
10
+ }
11
+ /**
12
+ * Application-owned upload boundary used by paste, drop, and file selection.
13
+ *
14
+ * @ownership The application owns network requests, persistence, and returned
15
+ * URLs. Looma owns only editor insertion after the promise fulfills.
16
+ * @failure Rejection leaves the document unchanged and is surfaced through the
17
+ * editor's `uploadError` event so the host chooses retry and user messaging.
18
+ */
19
+ export type LoomaImageUploader = (file: File) => Promise<string | LoomaImageUploadResult>;
20
+ export interface LoomaImageUploadsState {
21
+ readonly uploading: boolean;
22
+ /** The file whose upload failed last, which `retry()` sends again. */
23
+ readonly failed: File | null;
24
+ }
25
+ export interface LoomaImageUploadsOptions {
26
+ editor: Editor;
27
+ /** Read on each upload, so a host can change it while the editor runs. */
28
+ upload: () => LoomaImageUploader | null | undefined;
29
+ onError: (error: unknown, file: File) => void;
30
+ }
31
+ /**
32
+ * The editor's image uploads: each file goes to the host's uploader in turn, and the image it
33
+ * returns is inserted at the selection.
34
+ *
35
+ * @ownership The host owns the upload and its URL; this owns the uploading state, the failed file,
36
+ * and inserting the result.
37
+ * @failure A rejected upload leaves the document unchanged, records the file for `retry()`, and is
38
+ * reported through `onError`.
39
+ */
40
+ export interface LoomaImageUploads {
41
+ readonly state: LoomaImageUploadsState;
42
+ /** Calls `listener` after every state change; returns the unsubscribe function. */
43
+ subscribe(listener: () => void): () => void;
44
+ /** Uploads each file in turn and inserts its image; does nothing without an uploader. */
45
+ insert(files: Iterable<File>): Promise<void>;
46
+ /** Sends the failed file again. */
47
+ retry(): Promise<void>;
48
+ }
49
+ export declare function createImageUploads(options: LoomaImageUploadsOptions): LoomaImageUploads;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadlabs/looma",
3
- "version": "0.28.20",
3
+ "version": "0.28.22",
4
4
  "type": "module",
5
5
  "description": "Looma components as HTML, Vue, and plain DOM, with design tokens.",
6
6
  "license": "MIT",
@@ -3,25 +3,10 @@ import "./looma-editor.css";
3
3
  import { type AnyExtension, type Editor, type JSONContent } from "@tiptap/core";
4
4
  import { type PropType, type VNode } from "vue";
5
5
  import { type LoomaSlashCommand, type LoomaMentionItem, type LoomaMentionProvider, type LoomaCodeLanguages } from "@threadlabs/looma/editor";
6
- import { type LoomaImageActivationDetail, type LoomaImageAttributeResolver, type LoomaImageSourceResolver, type LoomaImageDescriptor, type LoomaImageRenditionErrorDetail } from "../../editor/image-delivery.js";
6
+ import { type LoomaImageActivationDetail, type LoomaImageAttributeResolver, type LoomaImageSourceResolver, type LoomaImageRenditionErrorDetail } from "../../editor/image-delivery.js";
7
+ import { type LoomaImageUploader } from "../../editor/image-uploads.js";
7
8
  import { type LoomaLinkResolve, type LoomaLinkSearch } from "../../editor/link-editing.js";
8
- /**
9
- * Host upload result normalized into the editor's durable image descriptor.
10
- * `url` becomes the stored `src`; optional dimensions preserve layout before
11
- * the image loads and `responsive` opts into host-provided rendition policy.
12
- */
13
- export interface LoomaImageUploadResult extends Omit<LoomaImageDescriptor, "src"> {
14
- url: string;
15
- }
16
- /**
17
- * Application-owned upload boundary used by paste, drop, and file selection.
18
- *
19
- * @ownership The application owns network requests, persistence, and returned
20
- * URLs. Looma owns only editor insertion after the promise fulfills.
21
- * @failure Rejection leaves the document unchanged and is surfaced through the
22
- * editor's `uploadError` event so the host chooses retry and user messaging.
23
- */
24
- export type LoomaImageUploader = (file: File) => Promise<string | LoomaImageUploadResult>;
9
+ export type { LoomaImageUploader, LoomaImageUploadResult } from "../../editor/image-uploads.js";
25
10
  /**
26
11
  * Chooses whether formatting controls follow a selection, occupy persistent
27
12
  * editor chrome, or open from an app-owned button (`popover`, with a text-only
@@ -1138,6 +1138,64 @@ function runBlockAction(editor, action) {
1138
1138
  return true;
1139
1139
  }
1140
1140
  //#endregion
1141
+ //#region src/editor/image-uploads.ts
1142
+ function createImageUploads(options) {
1143
+ const { editor } = options;
1144
+ const listeners = /* @__PURE__ */ new Set();
1145
+ let state = {
1146
+ uploading: false,
1147
+ failed: null
1148
+ };
1149
+ const set = (patch) => {
1150
+ state = {
1151
+ ...state,
1152
+ ...patch
1153
+ };
1154
+ listeners.forEach((listener) => listener());
1155
+ };
1156
+ const insertOne = async (file) => {
1157
+ const upload = options.upload();
1158
+ if (!upload || editor.isDestroyed) return;
1159
+ set({ uploading: true });
1160
+ try {
1161
+ const result = await upload(file);
1162
+ const image = typeof result === "string" ? { url: result } : result;
1163
+ const width = normalizeImageDimension(image.width);
1164
+ const height = normalizeImageDimension(image.height);
1165
+ if (!editor.isDestroyed) editor.chain().focus().insertContent(imageUploadContent({
1166
+ src: image.url,
1167
+ alt: image.alt ?? file.name,
1168
+ ...width ? { width } : {},
1169
+ ...height ? { height } : {},
1170
+ ...image.responsive === true ? { responsive: true } : {}
1171
+ })).run();
1172
+ set({ failed: null });
1173
+ } catch (error) {
1174
+ set({ failed: file });
1175
+ options.onError(error, file);
1176
+ } finally {
1177
+ set({ uploading: false });
1178
+ }
1179
+ };
1180
+ return {
1181
+ get state() {
1182
+ return state;
1183
+ },
1184
+ subscribe(listener) {
1185
+ listeners.add(listener);
1186
+ return () => {
1187
+ listeners.delete(listener);
1188
+ };
1189
+ },
1190
+ async insert(files) {
1191
+ for (const file of files) await insertOne(file);
1192
+ },
1193
+ async retry() {
1194
+ if (state.failed) await insertOne(state.failed);
1195
+ }
1196
+ };
1197
+ }
1198
+ //#endregion
1141
1199
  //#region src/editor/formatting-selection.ts
1142
1200
  var HOLD_MS = 500;
1143
1201
  var HOLD_SLOP_PX = 8;
@@ -3025,8 +3083,11 @@ var LoomaEditor = defineComponent({
3025
3083
  const mobileToolbarShell = ref(null);
3026
3084
  const codeLanguageShell = ref(null);
3027
3085
  const dragOver = ref(false);
3028
- const uploading = ref(false);
3029
- const failedUpload = ref(null);
3086
+ let uploads;
3087
+ const uploadState = shallowRef({
3088
+ uploading: false,
3089
+ failed: null
3090
+ });
3030
3091
  const tablePickerOpen = ref(false);
3031
3092
  const mobileToolbarStyle = ref({});
3032
3093
  const mobile = ref(typeof window !== "undefined" && window.innerWidth <= 767);
@@ -3263,6 +3324,15 @@ var LoomaEditor = defineComponent({
3263
3324
  linkState.value = engine.state;
3264
3325
  });
3265
3326
  linkEditing = engine;
3327
+ const imageUploads = createImageUploads({
3328
+ editor: session.editor,
3329
+ upload: () => props.uploadImage,
3330
+ onError: (error, file) => emit("uploadError", error, file)
3331
+ });
3332
+ imageUploads.subscribe(() => {
3333
+ uploadState.value = imageUploads.state;
3334
+ });
3335
+ uploads = imageUploads;
3266
3336
  const instance = session.editor;
3267
3337
  formatting = createFormattingSelection({
3268
3338
  editor: instance,
@@ -3867,41 +3937,18 @@ var LoomaEditor = defineComponent({
3867
3937
  if (dragLeaveTimer) clearTimeout(dragLeaveTimer);
3868
3938
  if (tablePointerFrame !== null) cancelAnimationFrame(tablePointerFrame);
3869
3939
  });
3870
- const insertImage = async (file) => {
3871
- if (!props.uploadImage || !editor.value) return;
3872
- uploading.value = true;
3873
- try {
3874
- const result = await props.uploadImage(file);
3875
- const image = typeof result === "string" ? { url: result } : result;
3876
- const width = normalizeImageDimension(image.width);
3877
- const height = normalizeImageDimension(image.height);
3878
- editor.value.chain().focus().insertContent(imageUploadContent({
3879
- src: image.url,
3880
- alt: image.alt ?? file.name,
3881
- ...width ? { width } : {},
3882
- ...height ? { height } : {},
3883
- ...image.responsive === true ? { responsive: true } : {}
3884
- })).run();
3885
- failedUpload.value = null;
3886
- } catch (error) {
3887
- failedUpload.value = { file };
3888
- emit("uploadError", error, file);
3889
- } finally {
3890
- uploading.value = false;
3891
- }
3892
- };
3893
3940
  const onFileChange = async (event) => {
3894
3941
  const input = event.target;
3895
3942
  const files = Array.from(input.files ?? []);
3896
3943
  input.value = "";
3897
- for (const file of files) await insertImage(file);
3944
+ await uploads?.insert(files);
3898
3945
  };
3899
3946
  const onDrop = async (event) => {
3900
3947
  event.preventDefault();
3901
3948
  dragOver.value = false;
3902
3949
  if (!props.editable) return;
3903
3950
  const files = Array.from(event.dataTransfer?.files ?? []).filter((file) => file.type.startsWith("image/"));
3904
- for (const file of files) await insertImage(file);
3951
+ await uploads?.insert(files);
3905
3952
  };
3906
3953
  const onContextMenu = (event) => {
3907
3954
  const instance = editor.value;
@@ -4158,7 +4205,7 @@ var LoomaEditor = defineComponent({
4158
4205
  variant: tablePickerOpen.value ? "solid" : "ghost",
4159
4206
  "aria-expanded": tablePickerOpen.value ? "true" : "false"
4160
4207
  }, () => loomaIcon("table")),
4161
- commandButton(uploading.value ? "Uploading image" : "Insert image", "image", false, uploading.value || !props.uploadImage, () => fileInput.value?.click()),
4208
+ commandButton(uploadState.value.uploading ? "Uploading image" : "Insert image", "image", false, uploadState.value.uploading || !props.uploadImage, () => fileInput.value?.click()),
4162
4209
  h("span", {
4163
4210
  class: "divider",
4164
4211
  "aria-hidden": "true"
@@ -4349,17 +4396,16 @@ var LoomaEditor = defineComponent({
4349
4396
  "aria-hidden": "true",
4350
4397
  onChange: onFileChange
4351
4398
  }),
4352
- failedUpload.value ? h("div", {
4399
+ uploadState.value.failed ? h("div", {
4353
4400
  class: "looma-editor__upload-error",
4354
4401
  role: "alert"
4355
4402
  }, [h("span", "Couldn’t upload image."), h("button", {
4356
4403
  type: "button",
4357
- disabled: uploading.value,
4404
+ disabled: uploadState.value.uploading,
4358
4405
  onClick: () => {
4359
- const attempt = failedUpload.value;
4360
- if (attempt) insertImage(attempt.file);
4406
+ uploads?.retry();
4361
4407
  }
4362
- }, uploading.value ? "Retrying…" : "Retry")]) : null,
4408
+ }, uploadState.value.uploading ? "Retrying…" : "Retry")]) : null,
4363
4409
  dragOver.value ? h("div", {
4364
4410
  class: "looma-editor__drop-overlay",
4365
4411
  "aria-hidden": "true"